/*
 * Copyright (c) 2016 Mastercard
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.simplify.ink;

import ohos.agp.components.AttrSet;
import ohos.agp.components.Component;
import ohos.agp.render.Canvas;
import ohos.agp.render.Paint;
import ohos.agp.render.PixelMapHolder;
import ohos.agp.render.Texture;
import ohos.agp.render.BlendMode;
import ohos.agp.utils.Color;
import ohos.agp.utils.Rect;
import ohos.agp.window.service.DisplayAttributes;
import ohos.agp.window.service.DisplayManager;
import ohos.app.Context;
import ohos.media.image.PixelMap;
import ohos.media.image.common.PixelFormat;
import ohos.media.image.common.Size;
import ohos.multimodalinput.event.MmiPoint;
import ohos.multimodalinput.event.TouchEvent;

import java.util.ArrayList;

/**
 * description : MainAbility
 *
 * @since 2021-05-29
 */
@SuppressWarnings("unused")
public class InkView extends Component implements Component.TouchEventListener,
        Component.DrawTask, Component.EstimateSizeListener {
    /**
     * The default maximum stroke width (dp).
     * Will be used as the standard stroke width if FLAG_RESPONSIVE_WIDTH is removed
     */
    public static final float DEFAULT_MAX_STROKE_WIDTH = 5f;

    /**
     * The default minimum stroke width (dp)
     */
    public static final float DEFAULT_MIN_STROKE_WIDTH = 1.5f;

    /**
     * The default smoothing ratio for calculating the control points for the bezier curves.
     * Will be ignored if FLAG_INTERPOLATION is removed
     */
    public static final float DEFAULT_SMOOTHING_RATIO = 0.75f;

    /**
     * When this flag is added, paths will be drawn as cubic-bezier curves
     */
    public static final int FLAG_INTERPOLATION = 1;

    /**
     * When present, the width of the paths will be responsive to the velocity of the stroke.
     * When missing, the width of the path will be the the max stroke width
     */
    public static final int FLAG_RESPONSIVE_WIDTH = 1 << 1;

    /**
     * When present, the data points for the path are drawn with their respective control points
     *
     * @deprecated This flag is no longer supported
     */
    @Deprecated
    public static final int FLAG_DEBUG = Integer.MIN_VALUE;

    // in/s
    static final float THRESHOLD_VELOCITY = 7f;

    // in/s^2
    static final float THRESHOLD_ACCELERATION = 3f;
    static final float FILTER_RATIO_MIN = 0.22f;
    static final float FILTER_RATIO_ACCELERATION_MODIFIER = 0.1f;
    static final int DEFAULT_FLAGS = FLAG_INTERPOLATION | FLAG_RESPONSIVE_WIDTH;
    static final int DEFAULT_STROKE_COLOR = 0xFF000000;

    // settings
    int flags;
    double maxStrokeWidth;
    double minStrokeWidth;
    double smoothingRatio;

    // points
    ArrayList<InkPoint> pointQueue = new ArrayList<>();
    ArrayList<InkPoint> pointRecycle = new ArrayList<>();

    // misc
    double density;
    PixelMap bitmap;
    Canvas canvas;
    Paint paint;
    Rect dirty;
    private float measureWidth;
    ArrayList<InkListener> listeners = new ArrayList<>();
    private Context mContext;
    private boolean isEmpty;

    /**
     * constructor
     *
     * @param context context
     */
    public InkView(Context context) {
        this(context, DEFAULT_FLAGS);
    }

    /**
     * constructor
     *
     * @param context context
     * @param flags flags
     */
    public InkView(Context context, int flags) {
        super(context);
        init(flags);
    }

    /**
     * constructor
     *
     * @param context context
     * @param attrs attrs
     */
    public InkView(Context context, AttrSet attrs) {
        this(context, attrs, 0);
    }

    /**
     * constructor
     *
     * @param context context
     * @param attrs attrs
     * @param defStyleAttr defStyleAttr
     */
    public InkView(Context context, AttrSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mContext = context;
        // get flags from attributes
        if (attrs.getAttr("ink_flags").isPresent()) {
            String inkFlags = attrs.getAttr("ink_flags").get().getStringValue();
            if (inkFlags.contains("|")) {
                String[] split = inkFlags.split("\\|");
                for (String str : split) {
                    int flag = stringToInt(str);
                    if (flag != -1) {
                        flags = flags | flag;
                    }
                }
            } else {
                flags = stringToInt(inkFlags);
            }
        } else {
            flags = DEFAULT_FLAGS;
        }
        setEstimateSizeListener(this);
        addDrawTask(this);
        setTouchEventListener(this);
        init(flags);
    }

    private int stringToInt(String ink_flags) {
        int result;
        switch (ink_flags) {
            case "non":
                result = 0;
                break;
            case "interpolation":
                result = 1;
                break;
            case "responsive_weight":
                result = 2;
                break;
            default:
                result = -1;
                break;
        }
        return result;
    }

    private void init(int flags) {
        // init flags
        setFlags(flags);

        // init screen density
        DisplayAttributes attributes = DisplayManager.getInstance().getDefaultDisplay(mContext).get().getAttributes();
        density = ((double)attributes.xDpi + (double)attributes.yDpi) / 2f;

        // init paint
        paint = new Paint();
        paint.setStrokeCap(Paint.StrokeCap.ROUND_CAP);
        paint.setAntiAlias(true);

        // apply default settings
        setColor(DEFAULT_STROKE_COLOR);
        setMaxStrokeWidth(DEFAULT_MAX_STROKE_WIDTH);
        setMinStrokeWidth(DEFAULT_MIN_STROKE_WIDTH);
        setSmoothingRatio(DEFAULT_SMOOTHING_RATIO);

        // init dirty rect
        dirty = new Rect();

        isEmpty = true;
    }

    @Override
    public boolean onTouchEvent(Component component, TouchEvent touchEvent) {
        int action = touchEvent.getAction();
        MmiPoint touchPoint = touchEvent.getPointerPosition(touchEvent.getIndex());
        isEmpty = false;
        // on down, initialize stroke point
        if (action == TouchEvent.PRIMARY_POINT_DOWN) {
            addPoint(getRecycledPoint(touchPoint.getX(), touchPoint.getY(), touchEvent.getOccurredTime()));
            // notify listeners of sign
            for (InkListener listener : listeners) {
                listener.onInkDraw();
            }
        }
        // on move, add next point
        else {
            if (action == TouchEvent.POINT_MOVE) {
                if (!pointQueue.get(pointQueue.size() - 1).equals(touchPoint.getX(), touchPoint.getY())) {
                    addPoint(getRecycledPoint(touchPoint.getX(), touchPoint.getY(), touchEvent.getOccurredTime()));
                }
            }
        }

        // on up, draw remaining queue
        if (action == TouchEvent.PRIMARY_POINT_UP) {
            // draw final points
            if (pointQueue.size() == 1) {
                draw(pointQueue.get(0));
            } else {
                if (pointQueue.size() == 2) {
                    pointQueue.get(1).findControlPoints(pointQueue.get(0), null);
                    draw(pointQueue.get(0), pointQueue.get(1));
                }
            }

            // recycle remaining points
            pointRecycle.addAll(pointQueue);
            pointQueue.clear();
        }

        return true;
    }

    @Override
    public void onDraw(Component component, Canvas canvas) {
        // simply paint the bitmap on the canvas
        PixelMapHolder pixelMapHolder = new PixelMapHolder(bitmap);
        canvas.drawPixelMapHolder(pixelMapHolder, 0, 0, paint);
    }

    /**
     * Sets the feature flags for the view. This will overwrite any previously set flag
     *
     * @param flags A bit mask of one or more flags (ie. FLAG_INTERPOLATION | FLAG_RESPONSIVE_WIDTH)
     */
    public void setFlags(int flags) {
        this.flags = flags;
    }

    /**
     * Adds the feature flag(s) to the view.
     *
     * @param flags A bit mask of one or more flags (ie. FLAG_INTERPOLATION | FLAG_RESPONSIVE_WIDTH)
     */
    public void addFlags(int flags) {
        this.flags |= flags;
    }

    /**
     * Alias for {@link #addFlags(int) addFlags}
     *
     * @param flag A feature flag (ie. FLAG_INTERPOLATION)
     */
    public void addFlag(int flag) {
        addFlags(flag);
    }

    /**
     * Removes the feature flag(s) from the view.
     *
     * @param flags A bit mask of one or more flags (ie. FLAG_INTERPOLATION | FLAG_RESPONSIVE_WIDTH)
     */
    public void removeFlags(int flags) {
        this.flags &= ~flags;
    }

    /**
     * Alias for {@link #removeFlags(int) removeFlags}
     *
     * @param flag A feature flag (ie. FLAG_INTERPOLATION)
     */
    public void removeFlag(int flag) {
        removeFlags(flag);
    }

    /**
     * Checks to see if the view has the supplied flag(s)
     *
     * @param flags A bit mask of one or more flags (ie. FLAG_INTERPOLATION | FLAG_RESPONSIVE_WIDTH)
     * @return True or False
     */
    public boolean hasFlags(int flags) {
        return (this.flags & flags) > 0;
    }

    /**
     * Alias for {@link #hasFlags(int flags) hasFlags}
     *
     * @param flag A feature flag (ie. FLAG_INTERPOLATION)
     * @return True or False
     */
    public boolean hasFlag(int flag) {
        return hasFlags(flag);
    }

    /**
     * Clears all feature flags from the view
     */
    public void clearFlags() {
        flags = 0;
    }

    /**
     * Adds a listener on the view
     *
     * @param listener The listener
     */
    public void addListener(InkListener listener) {
        if (!listeners.contains(listener)) {
            listeners.add(listener);
        }
    }

    /**
     * Adds a listener on the view
     *
     * @param listener The listener
     * @deprecated Use {@link #addListener(InkListener listener)} instead
     */
    @Deprecated
    public void addInkListener(InkListener listener) {
        addListener(listener);
    }

    /**
     * Removes the listener from the view
     *
     * @param listener The listener
     */
    public void removeListener(InkListener listener) {
        listeners.remove(listener);
    }

    /**
     * Removes the listener from the view
     *
     * @param listener The listener
     * @deprecated Use {@link #removeListener(InkListener listener)} instead
     */
    @Deprecated
    public void removeInkListener(InkListener listener) {
        removeListener(listener);
    }

    /**
     * Sets the stroke color. <p>
     * Examples:
     * <pre>
     * mInkView.setColor(Color.getIntColor("#F44336"));
     *
     * @param color The color value
     */
    public void setColor(int color) {
        paint.setColor(new Color(color));
    }

    /**
     * Sets the maximum stroke width
     *
     * @param width The width (in dp)
     */
    public void setMaxStrokeWidth(double width) {
        float dpi = DisplayManager.getInstance().getDefaultDisplay(mContext).get().getAttributes().densityPixels;
        maxStrokeWidth = (width * dpi + 0.5f * (width >= 0 ? 1 : -1));
    }

    /**
     * Sets the minimum stroke width
     *
     * @param width The width (in dp)
     */
    public void setMinStrokeWidth(double width) {
        float dpi = DisplayManager.getInstance().getDefaultDisplay(mContext).get().getAttributes().densityPixels;
        minStrokeWidth = (width * dpi + 0.5f * (width >= 0 ? 1 : -1));
    }

    /**
     * Returns the smoothing ratio
     *
     * @return The smoothing ratio
     */
    public double getSmoothingRatio() {
        return smoothingRatio;
    }

    /**
     * Sets the smoothing ratio for calculating control points.
     * This value is ignored when the FLAG_INTERPOLATING is removed
     *
     * @param ratio The smoothing ratio, between 0 and 1
     */
    public void setSmoothingRatio(float ratio) {
        smoothingRatio = Math.max(Math.min(ratio, 1f), 0f);
    }

    /**
     * Checks if the view is empty
     *
     * @return True of False
     */
    public boolean isViewEmpty() {
        return isEmpty;
    }

    /**
     * Clears the view
     */
    public void clear() {
        // clean up existing bitmap
        if (bitmap != null) {
            bitmap.release();
        }

        // init bitmap cache
        PixelMap.InitializationOptions options = new PixelMap.InitializationOptions();
        options.size = new Size(getEstimatedWidth(), getEstimatedHeight());
        options.pixelFormat = PixelFormat.ARGB_8888;
        options.editable = true;
        bitmap = PixelMap.create(options);
        PixelMapHolder pixelMapHolder = new PixelMapHolder(bitmap);
        canvas = new Canvas(new Texture(bitmap));
        canvas.drawPixelMapHolder(pixelMapHolder, 0, 0, new Paint());

        // notify listeners
        for (InkListener listener : listeners) {
            listener.onInkClear();
        }
        invalidate();
        isEmpty = true;
    }

    /**
     * Returns the bitmap of the drawing with a transparent background
     *
     * @return The bitmap
     */
    public PixelMap getBitmap() {
        return getBitmap(0);
    }

    /**
     * Returns the bitmap of the drawing with the specified background color
     *
     * @param backgroundColor The background color for the bitmap
     * @return The bitmap
     */
    public PixelMap getBitmap(int backgroundColor) {
        // create new pixelMap
        PixelMap.InitializationOptions options = new PixelMap.InitializationOptions();
        options.size = new Size(getWidth(), getHeight());
        options.pixelFormat = PixelFormat.ARGB_8888;
        PixelMap pixelMap = PixelMap.create(options);
        Canvas bitmapCanvas = new Canvas(new Texture(pixelMap));

        // draw background if not transparent
        if (backgroundColor != 0) {
            bitmapCanvas.drawColor(backgroundColor, BlendMode.COLOR);
        } else {
            bitmapCanvas.drawColor(Color.getIntColor("#ffffff"), BlendMode.COLOR);
        }

        // draw pixelMap
        PixelMapHolder pixelMapHolder = new PixelMapHolder(this.bitmap);
        bitmapCanvas.drawPixelMapHolder(pixelMapHolder, 0, 0, paint);
        return pixelMap;
    }

    /**
     * Draws a bitmap to the view, with its top left corner at (positionX,positionY)
     *
     * @param bitmap The bitmap to draw
     * @param positionX      The destination positionX coordinate of the bitmap in relation to the view
     * @param positionY      The destination positionY coordinate of the bitmap in relation to the view
     * @param paint  The paint used to draw the bitmap (may be null)
     */
    public void drawBitmap(PixelMap bitmap, float positionX, float positionY, Paint paint) {
        canvas.drawPixelMapHolder(new PixelMapHolder(bitmap), positionX, positionY, paint);
        invalidate();
    }

    @Override
    public boolean onEstimateSize(int widthEstimatedConfig, int heightEstimatedConfig) {
        int width = Component.EstimateSpec.getSize(widthEstimatedConfig);
        int height = Component.EstimateSpec.getSize(heightEstimatedConfig);
        setEstimatedSize(
                Component.EstimateSpec.getChildSizeWithMode(width, width, Component.EstimateSpec.NOT_EXCEED),
                Component.EstimateSpec.getChildSizeWithMode(height, height, Component.EstimateSpec.NOT_EXCEED));
        if (!(measureWidth == getEstimatedWidth())) {
            measureWidth = getEstimatedWidth();
            clear();
        }
        return true;
    }

    /**
     * Listener for the ink view to notify on actions
     */
    public interface InkListener {
        /**
         * Callback method when the ink view has been cleared
         */
        void onInkClear();

        /**
         * Callback method when the ink view receives a touch event
         * (Will be fired multiple times during a signing)
         */
        void onInkDraw();
    }

    double getDensity() {
        return density;
    }

    void addPoint(InkPoint point) {
        pointQueue.add(point);

        int queueSize = pointQueue.size();
        if (queueSize == 1) {
            // compute starting velocity
            int recycleSize = pointRecycle.size();
            point.velocity = (recycleSize > 0) ? pointRecycle.get(recycleSize - 1).velocityTo(point) / 2f : 0f;

            // compute starting stroke width
            paint.setStrokeWidth((float) computeStrokeWidth(point.velocity));
        } else if (queueSize == 2) {
            InkPoint p0 = pointQueue.get(0);

            // compute velocity for new point
            point.velocity = p0.velocityTo(point);

            // re-compute velocity for 1st point (predictive velocity)
            p0.velocity = p0.velocity + point.velocity / 2f;

            // find control points for first point
            p0.findControlPoints(null, point);

            // update starting stroke width
            paint.setStrokeWidth((float) computeStrokeWidth(p0.velocity));
        } else {
            if (queueSize == 3) {
                InkPoint p0 = pointQueue.get(0);
                InkPoint p1 = pointQueue.get(1);

                // find control points for second point
                p1.findControlPoints(p0, point);

                // compute velocity for new point
                point.velocity = p1.velocityTo(point);
                // draw geometry between first 2 points
                draw(p0, p1);

                // recycle 1st point
                pointRecycle.add(pointQueue.remove(0));
            }
        }
    }

    InkPoint getRecycledPoint(float positionX, float positionY, long time) {
        if (pointRecycle.size() == 0) {
            return new InkPoint(positionX, positionY, time);
        }
        return pointRecycle.remove(0).reset(positionX, positionY, time);
    }

    double computeStrokeWidth(double velocity) {
        // compute responsive width
        if (hasFlags(FLAG_RESPONSIVE_WIDTH)) {
            return maxStrokeWidth - (maxStrokeWidth - minStrokeWidth) * Math.min(velocity / THRESHOLD_VELOCITY, 1f);
        }
        return maxStrokeWidth;
    }

    void draw(InkPoint point) {
        paint.setStyle(Paint.Style.FILL_STYLE);
        // draw dot
        canvas.drawCircle((float) point.positionX, (float) point.positionY, (float) ((double)paint.getStrokeWidth() / 2f), paint);
        invalidate();
    }

    void draw(InkPoint p1, InkPoint p2) {
        // init dirty rect
        dirty.left = (int) Math.min(p1.positionX, p2.positionX);
        dirty.right = (int) Math.max(p1.positionX, p2.positionX);
        dirty.top = (int) Math.min(p1.positionY, p2.positionY);
        dirty.bottom = (int) Math.max(p1.positionY, p2.positionY);
        paint.setStyle(Paint.Style.STROKE_STYLE);

        // adjust low-pass ratio from changing acceleration
        // using comfortable range of 0.2 -> 0.3 approx.
        double acceleration = Math.abs((p2.velocity - p1.velocity) / (p2.time - p1.time)); // in/s^2
        double filterRatio = Math.min(FILTER_RATIO_MIN +
                FILTER_RATIO_ACCELERATION_MODIFIER * acceleration / THRESHOLD_ACCELERATION, 1f);

        // compute new stroke width
        double desiredWidth = computeStrokeWidth(p2.velocity);
        double startWidth = paint.getStrokeWidth();
        double endWidth = filterRatio * desiredWidth + (1f - filterRatio) * startWidth;
        double deltaWidth = endWidth - startWidth;
        // interpolate bezier curve
        if (hasFlags(FLAG_INTERPOLATION)) {
            // compute # of steps to interpolate in the bezier curve
            int steps = (int) (Math.sqrt(Math.pow(p2.positionX - p1.positionX, 2) + Math.pow(p2.positionY - p1.positionY, 2)) / 5);
            // computational setup for differentials used to interpolate the bezier curve
            double u1 = 1f / (steps + 1);
            double uu = u1 * u1;
            double uuu = u1 * u1 * u1;

            double pre1 = 3f * u1;
            double pre2 = 3f * uu;
            double pre3 = 6f * uu;
            double pre4 = 6f * uuu;

            double tmp1x = p1.positionX - p1.c2x * 2f + p2.c1x;
            double tmp1y = p1.positionY - p1.c2y * 2f + p2.c1y;
            double tmp2x = (p1.c2x - p2.c1x) * 3f - p1.positionX + p2.positionX;
            double tmp2y = (p1.c2y - p2.c1y) * 3f - p1.positionY + p2.positionY;

            double dx = (p1.c2x - p1.positionX) * pre1 + tmp1x * pre2 + tmp2x * uuu;
            double dy = (p1.c2y - p1.positionY) * pre1 + tmp1y * pre2 + tmp2y * uuu;
            double ddx = tmp1x * pre3 + tmp2x * pre4;
            double ddy = tmp1y * pre3 + tmp2y * pre4;
            double dddx = tmp2x * pre4;
            double dddy = tmp2y * pre4;

            double x1 = p1.positionX;
            double y1 = p1.positionY;
            double x2;
            double y2;

            // iterate over each step and draw the curve
            int index = 0;
            while (index++ < steps) {
                x2 = x1 + dx;
                y2 = y1 + dy;
                paint.setStrokeWidth((float) (startWidth + deltaWidth * index / steps));
                canvas.drawLine((float) x1, (float) y1, (float) x2, (float) y2, paint);
                x1 = x2;
                y1 = y2;
                dx += ddx;
                dy += ddy;
                ddx += dddx;
                ddy += dddy;

                // adjust dirty bounds to account for curve
                dirty.left = (int) Math.min(dirty.left, x1);
                dirty.right = (int) Math.max(dirty.right, x1);
                dirty.top = (int) Math.min(dirty.top, y1);
                dirty.bottom = (int) Math.max(dirty.bottom, y1);
            }
            paint.setStrokeWidth((float) endWidth);
            canvas.drawLine((float)x1, (float)y1, (float)p2.positionX, (float)p2.positionY, paint);
        }
        // no interpolation, draw line between points
        else {
            canvas.drawLine((float)p1.positionX, (float)p1.positionY, (float)p2.positionX, (float)p2.positionY, paint);
            paint.setStrokeWidth((float)endWidth);
        }
        invalidate();
    }

    class InkPoint {

        double positionX;
        double positionY;
        double c1x;
        double c1y;
        double c2x;
        double c2y;
        double velocity;
        long time;

        InkPoint(float positionX, float positionY, long time) {
            reset(positionX, positionY, time);
        }

        InkPoint reset(float positionX, float positionY, long time) {
            this.positionX = positionX;
            this.positionY = positionY;
            this.time = time;
            velocity = 0f;
            c1x = positionX;
            c1y = positionY;
            c2x = positionX;
            c2y = positionY;
            return this;
        }

        boolean equals(float positionX, float positionY) {
            return this.positionX == positionX && this.positionY == positionY;
        }

        double distanceTo(InkPoint point) {
            double dx = point.positionX - positionX;
            double dy = point.positionY - positionY;
            return (float) Math.sqrt(dx * dx + dy * dy);
        }

        double velocityTo(InkPoint point) {
            // in/s
            return (1000f * distanceTo(point)) / (Math.abs(point.time - time) * getDensity());
        }

        void findControlPoints(InkPoint prev, InkPoint next) {
            if (prev == null && next == null) {
                return;
            }
            double ratio = getSmoothingRatio();
            // if start of a stroke, c2 control points half-way between this and next point
            if (prev == null) {
                c2x = positionX + ratio * (next.positionX - positionX) / 2f;
                c2y = positionY + ratio * (next.positionY - positionY) / 2f;
                return;
            }
            // if end of a stroke, c1 control points half-way between this and prev point
            if (next == null) {
                c1x = positionX + ratio * (prev.positionX - positionX) / 2f;
                c1y = positionY + ratio * (prev.positionY - positionY) / 2f;
                return;
            }
            // init control points
            c1x = (positionX + prev.positionX) / 2f;
            c1y = (positionY + prev.positionY) / 2f;
            c2x = (positionX + next.positionX) / 2f;
            c2y = (positionY + next.positionY) / 2f;

            // calculate control offsets
            double len1 = distanceTo(prev);
            double len2 = distanceTo(next);
            double k1 = len1 / (len1 + len2);
            double xM = c1x + (c2x - c1x) * k1;
            double yM = c1y + (c2y - c1y) * k1;
            double dx = positionX - xM;
            double dy = positionY - yM;

            // inverse smoothing ratio
            ratio = 1f - ratio;

            // translate control points
            c1x += dx + ratio * (xM - c1x);
            c1y += dy + ratio * (yM - c1y);
            c2x += dx + ratio * (xM - c2x);
            c2y += dy + ratio * (yM - c2y);
        }
    }
}
